diff --git a/cmd/ghcs/common.go b/cmd/ghcs/common.go index fc7acef2f..4ebc89a2d 100644 --- a/cmd/ghcs/common.go +++ b/cmd/ghcs/common.go @@ -19,11 +19,14 @@ import ( 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) + codespaces, err := apiClient.ListCodespaces(ctx, user.Login) if err != nil { return nil, fmt.Errorf("error getting codespaces: %w", err) } + return chooseCodespaceFromList(ctx, codespaces) +} +func chooseCodespaceFromList(ctx context.Context, codespaces []*api.Codespace) (*api.Codespace, error) { if len(codespaces) == 0 { return nil, errNoCodespaces } diff --git a/cmd/ghcs/delete.go b/cmd/ghcs/delete.go index 70311c884..94aaaf214 100644 --- a/cmd/ghcs/delete.go +++ b/cmd/ghcs/delete.go @@ -6,216 +6,179 @@ import ( "fmt" "os" "strings" - "sync" + "time" "github.com/AlecAivazis/survey/v2" "github.com/github/ghcs/cmd/ghcs/output" "github.com/github/ghcs/internal/api" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" ) -func newDeleteCmd() *cobra.Command { - var ( - codespace string - allCodespaces bool - repo string - force bool - ) +type deleteOptions struct { + deleteAll bool + skipConfirm bool + codespaceName string + repoFilter string + keepDays uint16 + + isInteractive bool + now func() time.Time + apiClient apiClient + prompter prompter +} + +//go:generate moq -fmt goimports -rm -skip-ensure -out mock_prompter.go . prompter +type prompter interface { + Confirm(message string) (bool, error) +} + +//go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient +type apiClient interface { + GetUser(ctx context.Context) (*api.User, error) + GetCodespaceToken(ctx context.Context, user, name string) (string, error) + GetCodespace(ctx context.Context, token, user, name string) (*api.Codespace, error) + ListCodespaces(ctx context.Context, user string) ([]*api.Codespace, error) + DeleteCodespace(ctx context.Context, user, name string) error +} + +func newDeleteCmd() *cobra.Command { + opts := deleteOptions{ + isInteractive: hasTTY, + now: time.Now, + apiClient: api.New(os.Getenv("GITHUB_TOKEN")), + prompter: &surveyPrompter{}, + } - log := output.NewLogger(os.Stdout, os.Stderr, false) deleteCmd := &cobra.Command{ Use: "delete", Short: "Delete a codespace", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - return fmt.Errorf("delete: unexpected positional arguments") - } - switch { - case allCodespaces && repo != "": + if opts.deleteAll && opts.repoFilter != "" { return errors.New("both --all and --repo is not supported") - case allCodespaces: - return deleteAll(log, force) - case repo != "": - return deleteByRepo(log, repo, force) - default: - return delete_(log, codespace, force) } + log := output.NewLogger(cmd.OutOrStdout(), cmd.ErrOrStderr(), !opts.isInteractive) + return delete(context.Background(), log, opts) }, } - deleteCmd.Flags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace") - deleteCmd.Flags().BoolVar(&allCodespaces, "all", false, "Delete all codespaces") - deleteCmd.Flags().StringVarP(&repo, "repo", "r", "", "Delete all codespaces for a repository") - deleteCmd.Flags().BoolVarP(&force, "force", "f", false, "Delete codespaces with unsaved changes without confirmation") + deleteCmd.Flags().StringVarP(&opts.codespaceName, "codespace", "c", "", "Name of the codespace") + deleteCmd.Flags().BoolVar(&opts.deleteAll, "all", false, "Delete all codespaces") + deleteCmd.Flags().StringVarP(&opts.repoFilter, "repo", "r", "", "Delete codespaces for a `repository`") + deleteCmd.Flags().BoolVarP(&opts.skipConfirm, "force", "f", false, "Skip confirmation for codespaces that contain unsaved changes") + deleteCmd.Flags().Uint16Var(&opts.keepDays, "days", 0, "Delete codespaces older than `N` days") return deleteCmd } -func delete_(log *output.Logger, codespaceName string, force bool) error { - apiClient := api.New(GithubToken) - ctx := context.Background() - - user, err := apiClient.GetUser(ctx) - if err != nil { - return fmt.Errorf("error getting user: %w", err) - } - - codespace, token, err := getOrChooseCodespace(ctx, apiClient, user, codespaceName) - if err != nil { - return fmt.Errorf("get or choose codespace: %w", err) - } - - confirmed, err := confirmDeletion(codespace, force) - if err != nil { - return fmt.Errorf("deletion could not be confirmed: %w", err) - } - - if !confirmed { - return nil - } - - if err := apiClient.DeleteCodespace(ctx, user, token, codespace.Name); err != nil { - return fmt.Errorf("error deleting codespace: %w", err) - } - - log.Println("Codespace deleted.") - - return list(&listOptions{}) +type logger interface { + Errorf(format string, v ...interface{}) (int, error) } -func deleteAll(log *output.Logger, force bool) error { - apiClient := api.New(GithubToken) - ctx := context.Background() - - user, err := apiClient.GetUser(ctx) +func delete(ctx context.Context, log logger, opts deleteOptions) error { + user, err := opts.apiClient.GetUser(ctx) if err != nil { return fmt.Errorf("error getting user: %w", err) } - codespaces, err := apiClient.ListCodespaces(ctx, user) - if err != nil { - return fmt.Errorf("error getting codespaces: %w", err) - } - - for _, c := range codespaces { - confirmed, err := confirmDeletion(c, force) + var codespaces []*api.Codespace + nameFilter := opts.codespaceName + if nameFilter == "" { + codespaces, err = opts.apiClient.ListCodespaces(ctx, user.Login) if err != nil { - return fmt.Errorf("deletion could not be confirmed: %w", err) + return fmt.Errorf("error getting codespaces: %w", err) } - if !confirmed { - continue - } - - token, err := apiClient.GetCodespaceToken(ctx, user.Login, c.Name) - if err != nil { - return fmt.Errorf("error getting codespace token: %w", err) - } - - if err := apiClient.DeleteCodespace(ctx, user, token, c.Name); err != nil { - return fmt.Errorf("error deleting codespace: %w", err) - } - - log.Printf("Codespace deleted: %s\n", c.Name) - } - - return list(&listOptions{}) -} - -func deleteByRepo(log *output.Logger, repo string, force bool) error { - apiClient := api.New(GithubToken) - ctx := context.Background() - - user, err := apiClient.GetUser(ctx) - if err != nil { - return fmt.Errorf("error getting user: %w", err) - } - - codespaces, err := apiClient.ListCodespaces(ctx, user) - if err != nil { - return fmt.Errorf("error getting codespaces: %w", err) - } - - delete := func(name string) error { - token, err := apiClient.GetCodespaceToken(ctx, user.Login, name) - if err != nil { - return fmt.Errorf("error getting codespace token: %w", err) - } - - if err := apiClient.DeleteCodespace(ctx, user, token, name); err != nil { - return fmt.Errorf("error deleting codespace: %w", err) - } - - return nil - } - - // Perform deletions in parallel, for performance, - // and to ensure all are attempted even if any one fails. - var ( - found bool - mu sync.Mutex // guards errs, logger - errs []error - wg sync.WaitGroup - ) - for _, c := range codespaces { - if !strings.EqualFold(c.RepositoryNWO, repo) { - continue - } - - confirmed, err := confirmDeletion(c, force) - if err != nil { - mu.Lock() - errs = append(errs, fmt.Errorf("deletion could not be confirmed: %w", err)) - mu.Unlock() - continue - } - - if !confirmed { - continue - } - - found = true - c := c - wg.Add(1) - go func() { - defer wg.Done() - err := delete(c.Name) - mu.Lock() - defer mu.Unlock() + if !opts.deleteAll && opts.repoFilter == "" { + c, err := chooseCodespaceFromList(ctx, codespaces) if err != nil { - errs = append(errs, err) - } else { - log.Printf("Codespace deleted: %s\n", c.Name) + return fmt.Errorf("error choosing codespace: %w", err) } - }() - } - if !found { - return fmt.Errorf("no codespace was found for repository: %s", repo) - } - wg.Wait() - - // Return first error, plus count of others. - if errs != nil { - err := errs[0] - if others := len(errs) - 1; others > 0 { - err = fmt.Errorf("%w (+%d more)", err, others) + nameFilter = c.Name } - return err + } else { + // TODO: this token is discarded and then re-requested later in DeleteCodespace + token, err := opts.apiClient.GetCodespaceToken(ctx, user.Login, nameFilter) + if err != nil { + return fmt.Errorf("error getting codespace token: %w", err) + } + + codespace, err := opts.apiClient.GetCodespace(ctx, token, user.Login, nameFilter) + if err != nil { + return fmt.Errorf("error fetching codespace information: %w", err) + } + + codespaces = []*api.Codespace{codespace} } + codespacesToDelete := make([]*api.Codespace, 0, len(codespaces)) + lastUpdatedCutoffTime := opts.now().AddDate(0, 0, -int(opts.keepDays)) + for _, c := range codespaces { + if nameFilter != "" && c.Name != nameFilter { + continue + } + if opts.repoFilter != "" && !strings.EqualFold(c.RepositoryNWO, opts.repoFilter) { + continue + } + if opts.keepDays > 0 { + t, err := time.Parse(time.RFC3339, c.LastUsedAt) + if err != nil { + return fmt.Errorf("error parsing last_used_at timestamp %q: %w", c.LastUsedAt, err) + } + if t.After(lastUpdatedCutoffTime) { + continue + } + } + if !opts.skipConfirm { + confirmed, err := confirmDeletion(opts.prompter, c, opts.isInteractive) + if err != nil { + return fmt.Errorf("unable to confirm: %w", err) + } + if !confirmed { + continue + } + } + codespacesToDelete = append(codespacesToDelete, c) + } + + if len(codespacesToDelete) == 0 { + return errors.New("no codespaces to delete") + } + + g := errgroup.Group{} + for _, c := range codespacesToDelete { + codespaceName := c.Name + g.Go(func() error { + if err := opts.apiClient.DeleteCodespace(ctx, user.Login, codespaceName); err != nil { + _, _ = log.Errorf("error deleting codespace %q: %v\n", codespaceName, err) + return err + } + return nil + }) + } + + if err := g.Wait(); err != nil { + return errors.New("some codespaces failed to delete") + } return nil } -func confirmDeletion(codespace *api.Codespace, force bool) (bool, error) { +func confirmDeletion(p prompter, codespace *api.Codespace, isInteractive bool) (bool, error) { gs := codespace.Environment.GitStatus hasUnsavedChanges := gs.HasUncommitedChanges || gs.HasUnpushedChanges - if force || !hasUnsavedChanges { + if !hasUnsavedChanges { return true, nil } - if !hasTTY { + if !isInteractive { return false, fmt.Errorf("codespace %s has unsaved changes (use --force to override)", codespace.Name) } + return p.Confirm(fmt.Sprintf("Codespace %s has unsaved changes. OK to delete?", codespace.Name)) +} +type surveyPrompter struct{} + +func (p *surveyPrompter) Confirm(message string) (bool, error) { var confirmed struct { Confirmed bool } @@ -223,7 +186,7 @@ func confirmDeletion(codespace *api.Codespace, force bool) (bool, error) { { Name: "confirmed", Prompt: &survey.Confirm{ - Message: fmt.Sprintf("Codespace %s has unsaved changes. OK to delete?", codespace.Name), + Message: message, }, }, } diff --git a/cmd/ghcs/delete_test.go b/cmd/ghcs/delete_test.go new file mode 100644 index 000000000..47e6a4d6c --- /dev/null +++ b/cmd/ghcs/delete_test.go @@ -0,0 +1,239 @@ +package ghcs + +import ( + "bytes" + "context" + "errors" + "fmt" + "sort" + "testing" + "time" + + "github.com/github/ghcs/cmd/ghcs/output" + "github.com/github/ghcs/internal/api" +) + +func TestDelete(t *testing.T) { + user := &api.User{Login: "hubot"} + now, _ := time.Parse(time.RFC3339, "2021-09-22T00:00:00Z") + daysAgo := func(n int) string { + return now.Add(time.Hour * -time.Duration(24*n)).Format(time.RFC3339) + } + + tests := []struct { + name string + opts deleteOptions + codespaces []*api.Codespace + confirms map[string]bool + deleteErr error + wantErr bool + wantDeleted []string + wantStdout string + wantStderr string + }{ + { + name: "by name", + opts: deleteOptions{ + codespaceName: "hubot-robawt-abc", + }, + codespaces: []*api.Codespace{ + { + Name: "hubot-robawt-abc", + }, + }, + wantDeleted: []string{"hubot-robawt-abc"}, + }, + { + name: "by repo", + opts: deleteOptions{ + repoFilter: "monalisa/spoon-knife", + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + RepositoryNWO: "monalisa/Spoon-Knife", + }, + { + Name: "hubot-robawt-abc", + RepositoryNWO: "hubot/ROBAWT", + }, + { + Name: "monalisa-spoonknife-c4f3", + RepositoryNWO: "monalisa/Spoon-Knife", + }, + }, + wantDeleted: []string{"monalisa-spoonknife-123", "monalisa-spoonknife-c4f3"}, + }, + { + name: "unused", + opts: deleteOptions{ + deleteAll: true, + keepDays: 3, + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + LastUsedAt: daysAgo(1), + }, + { + Name: "hubot-robawt-abc", + LastUsedAt: daysAgo(4), + }, + { + Name: "monalisa-spoonknife-c4f3", + LastUsedAt: daysAgo(10), + }, + }, + wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"}, + }, + { + name: "deletion failed", + opts: deleteOptions{ + deleteAll: true, + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + }, + { + Name: "hubot-robawt-abc", + }, + }, + deleteErr: errors.New("aborted by test"), + wantErr: true, + wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-123"}, + wantStderr: "error deleting codespace \"hubot-robawt-abc\": aborted by test\nerror deleting codespace \"monalisa-spoonknife-123\": aborted by test\n", + }, + { + name: "with confirm", + opts: deleteOptions{ + isInteractive: true, + deleteAll: true, + skipConfirm: false, + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + Environment: api.CodespaceEnvironment{ + GitStatus: api.CodespaceEnvironmentGitStatus{ + HasUnpushedChanges: true, + }, + }, + }, + { + Name: "hubot-robawt-abc", + Environment: api.CodespaceEnvironment{ + GitStatus: api.CodespaceEnvironmentGitStatus{ + HasUncommitedChanges: true, + }, + }, + }, + { + Name: "monalisa-spoonknife-c4f3", + Environment: api.CodespaceEnvironment{ + GitStatus: api.CodespaceEnvironmentGitStatus{ + HasUnpushedChanges: false, + HasUncommitedChanges: false, + }, + }, + }, + }, + confirms: map[string]bool{ + "Codespace monalisa-spoonknife-123 has unsaved changes. OK to delete?": false, + "Codespace hubot-robawt-abc has unsaved changes. OK to delete?": true, + }, + wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiMock := &apiClientMock{ + GetUserFunc: func(_ context.Context) (*api.User, error) { + return user, nil + }, + DeleteCodespaceFunc: func(_ context.Context, userLogin, name string) error { + if userLogin != user.Login { + return fmt.Errorf("unexpected user %q", userLogin) + } + if tt.deleteErr != nil { + return tt.deleteErr + } + return nil + }, + } + if tt.opts.codespaceName == "" { + apiMock.ListCodespacesFunc = func(_ context.Context, userLogin string) ([]*api.Codespace, error) { + if userLogin != user.Login { + return nil, fmt.Errorf("unexpected user %q", userLogin) + } + return tt.codespaces, nil + } + } else { + apiMock.GetCodespaceTokenFunc = func(_ context.Context, userLogin, name string) (string, error) { + if userLogin != user.Login { + return "", fmt.Errorf("unexpected user %q", userLogin) + } + return "CS_TOKEN", nil + } + apiMock.GetCodespaceFunc = func(_ context.Context, token, userLogin, name string) (*api.Codespace, error) { + if userLogin != user.Login { + return nil, fmt.Errorf("unexpected user %q", userLogin) + } + if token != "CS_TOKEN" { + return nil, fmt.Errorf("unexpected token %q", token) + } + return tt.codespaces[0], nil + } + } + opts := tt.opts + opts.apiClient = apiMock + opts.now = func() time.Time { return now } + opts.prompter = &prompterMock{ + ConfirmFunc: func(msg string) (bool, error) { + res, found := tt.confirms[msg] + if !found { + return false, fmt.Errorf("unexpected prompt %q", msg) + } + return res, nil + }, + } + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + log := output.NewLogger(stdout, stderr, false) + err := delete(context.Background(), log, opts) + if (err != nil) != tt.wantErr { + t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr) + } + if n := len(apiMock.GetUserCalls()); n != 1 { + t.Errorf("GetUser invoked %d times, expected %d", n, 1) + } + var gotDeleted []string + for _, delArgs := range apiMock.DeleteCodespaceCalls() { + gotDeleted = append(gotDeleted, delArgs.Name) + } + sort.Strings(gotDeleted) + if !sliceEquals(gotDeleted, tt.wantDeleted) { + t.Errorf("deleted %q, want %q", gotDeleted, tt.wantDeleted) + } + if out := stdout.String(); out != tt.wantStdout { + t.Errorf("stdout = %q, want %q", out, tt.wantStdout) + } + if out := stderr.String(); out != tt.wantStderr { + t.Errorf("stderr = %q, want %q", out, tt.wantStderr) + } + }) + } +} + +func sliceEquals(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/cmd/ghcs/list.go b/cmd/ghcs/list.go index 7ee156012..85eabaef5 100644 --- a/cmd/ghcs/list.go +++ b/cmd/ghcs/list.go @@ -40,7 +40,7 @@ func list(opts *listOptions) error { return fmt.Errorf("error getting user: %w", err) } - codespaces, err := apiClient.ListCodespaces(ctx, user) + codespaces, err := apiClient.ListCodespaces(ctx, user.Login) if err != nil { return fmt.Errorf("error getting codespaces: %w", err) } diff --git a/cmd/ghcs/mock_api.go b/cmd/ghcs/mock_api.go new file mode 100644 index 000000000..256a30ec3 --- /dev/null +++ b/cmd/ghcs/mock_api.go @@ -0,0 +1,292 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package ghcs + +import ( + "context" + "sync" + + "github.com/github/ghcs/internal/api" +) + +// apiClientMock is a mock implementation of apiClient. +// +// func TestSomethingThatUsesapiClient(t *testing.T) { +// +// // make and configure a mocked apiClient +// mockedapiClient := &apiClientMock{ +// DeleteCodespaceFunc: func(ctx context.Context, user string, name string) error { +// panic("mock out the DeleteCodespace method") +// }, +// GetCodespaceFunc: func(ctx context.Context, token string, user string, name string) (*api.Codespace, error) { +// panic("mock out the GetCodespace method") +// }, +// GetCodespaceTokenFunc: func(ctx context.Context, user string, name string) (string, error) { +// panic("mock out the GetCodespaceToken method") +// }, +// GetUserFunc: func(ctx context.Context) (*api.User, error) { +// panic("mock out the GetUser method") +// }, +// ListCodespacesFunc: func(ctx context.Context, user string) ([]*api.Codespace, error) { +// panic("mock out the ListCodespaces method") +// }, +// } +// +// // use mockedapiClient in code that requires apiClient +// // and then make assertions. +// +// } +type apiClientMock struct { + // DeleteCodespaceFunc mocks the DeleteCodespace method. + DeleteCodespaceFunc func(ctx context.Context, user string, name string) error + + // GetCodespaceFunc mocks the GetCodespace method. + GetCodespaceFunc func(ctx context.Context, token string, user string, name string) (*api.Codespace, error) + + // GetCodespaceTokenFunc mocks the GetCodespaceToken method. + GetCodespaceTokenFunc func(ctx context.Context, user string, name string) (string, error) + + // GetUserFunc mocks the GetUser method. + GetUserFunc func(ctx context.Context) (*api.User, error) + + // ListCodespacesFunc mocks the ListCodespaces method. + ListCodespacesFunc func(ctx context.Context, user string) ([]*api.Codespace, error) + + // calls tracks calls to the methods. + calls struct { + // DeleteCodespace holds details about calls to the DeleteCodespace method. + DeleteCodespace []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // User is the user argument value. + User string + // Name is the name argument value. + Name string + } + // GetCodespace holds details about calls to the GetCodespace method. + GetCodespace []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Token is the token argument value. + Token string + // User is the user argument value. + User string + // Name is the name argument value. + Name string + } + // GetCodespaceToken holds details about calls to the GetCodespaceToken method. + GetCodespaceToken []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // User is the user argument value. + User string + // Name is the name argument value. + Name string + } + // GetUser holds details about calls to the GetUser method. + GetUser []struct { + // Ctx is the ctx argument value. + Ctx context.Context + } + // ListCodespaces holds details about calls to the ListCodespaces method. + ListCodespaces []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // User is the user argument value. + User string + } + } + lockDeleteCodespace sync.RWMutex + lockGetCodespace sync.RWMutex + lockGetCodespaceToken sync.RWMutex + lockGetUser sync.RWMutex + lockListCodespaces sync.RWMutex +} + +// DeleteCodespace calls DeleteCodespaceFunc. +func (mock *apiClientMock) DeleteCodespace(ctx context.Context, user string, name string) error { + if mock.DeleteCodespaceFunc == nil { + panic("apiClientMock.DeleteCodespaceFunc: method is nil but apiClient.DeleteCodespace was just called") + } + callInfo := struct { + Ctx context.Context + User string + Name string + }{ + Ctx: ctx, + User: user, + Name: name, + } + mock.lockDeleteCodespace.Lock() + mock.calls.DeleteCodespace = append(mock.calls.DeleteCodespace, callInfo) + mock.lockDeleteCodespace.Unlock() + return mock.DeleteCodespaceFunc(ctx, user, name) +} + +// DeleteCodespaceCalls gets all the calls that were made to DeleteCodespace. +// Check the length with: +// len(mockedapiClient.DeleteCodespaceCalls()) +func (mock *apiClientMock) DeleteCodespaceCalls() []struct { + Ctx context.Context + User string + Name string +} { + var calls []struct { + Ctx context.Context + User string + Name string + } + mock.lockDeleteCodespace.RLock() + calls = mock.calls.DeleteCodespace + mock.lockDeleteCodespace.RUnlock() + return calls +} + +// GetCodespace calls GetCodespaceFunc. +func (mock *apiClientMock) GetCodespace(ctx context.Context, token string, user string, name string) (*api.Codespace, error) { + if mock.GetCodespaceFunc == nil { + panic("apiClientMock.GetCodespaceFunc: method is nil but apiClient.GetCodespace was just called") + } + callInfo := struct { + Ctx context.Context + Token string + User string + Name string + }{ + Ctx: ctx, + Token: token, + User: user, + Name: name, + } + mock.lockGetCodespace.Lock() + mock.calls.GetCodespace = append(mock.calls.GetCodespace, callInfo) + mock.lockGetCodespace.Unlock() + return mock.GetCodespaceFunc(ctx, token, user, name) +} + +// GetCodespaceCalls gets all the calls that were made to GetCodespace. +// Check the length with: +// len(mockedapiClient.GetCodespaceCalls()) +func (mock *apiClientMock) GetCodespaceCalls() []struct { + Ctx context.Context + Token string + User string + Name string +} { + var calls []struct { + Ctx context.Context + Token string + User string + Name string + } + mock.lockGetCodespace.RLock() + calls = mock.calls.GetCodespace + mock.lockGetCodespace.RUnlock() + return calls +} + +// GetCodespaceToken calls GetCodespaceTokenFunc. +func (mock *apiClientMock) GetCodespaceToken(ctx context.Context, user string, name string) (string, error) { + if mock.GetCodespaceTokenFunc == nil { + panic("apiClientMock.GetCodespaceTokenFunc: method is nil but apiClient.GetCodespaceToken was just called") + } + callInfo := struct { + Ctx context.Context + User string + Name string + }{ + Ctx: ctx, + User: user, + Name: name, + } + mock.lockGetCodespaceToken.Lock() + mock.calls.GetCodespaceToken = append(mock.calls.GetCodespaceToken, callInfo) + mock.lockGetCodespaceToken.Unlock() + return mock.GetCodespaceTokenFunc(ctx, user, name) +} + +// GetCodespaceTokenCalls gets all the calls that were made to GetCodespaceToken. +// Check the length with: +// len(mockedapiClient.GetCodespaceTokenCalls()) +func (mock *apiClientMock) GetCodespaceTokenCalls() []struct { + Ctx context.Context + User string + Name string +} { + var calls []struct { + Ctx context.Context + User string + Name string + } + mock.lockGetCodespaceToken.RLock() + calls = mock.calls.GetCodespaceToken + mock.lockGetCodespaceToken.RUnlock() + return calls +} + +// GetUser calls GetUserFunc. +func (mock *apiClientMock) GetUser(ctx context.Context) (*api.User, error) { + if mock.GetUserFunc == nil { + panic("apiClientMock.GetUserFunc: method is nil but apiClient.GetUser was just called") + } + callInfo := struct { + Ctx context.Context + }{ + Ctx: ctx, + } + mock.lockGetUser.Lock() + mock.calls.GetUser = append(mock.calls.GetUser, callInfo) + mock.lockGetUser.Unlock() + return mock.GetUserFunc(ctx) +} + +// GetUserCalls gets all the calls that were made to GetUser. +// Check the length with: +// len(mockedapiClient.GetUserCalls()) +func (mock *apiClientMock) GetUserCalls() []struct { + Ctx context.Context +} { + var calls []struct { + Ctx context.Context + } + mock.lockGetUser.RLock() + calls = mock.calls.GetUser + mock.lockGetUser.RUnlock() + return calls +} + +// ListCodespaces calls ListCodespacesFunc. +func (mock *apiClientMock) ListCodespaces(ctx context.Context, user string) ([]*api.Codespace, error) { + if mock.ListCodespacesFunc == nil { + panic("apiClientMock.ListCodespacesFunc: method is nil but apiClient.ListCodespaces was just called") + } + callInfo := struct { + Ctx context.Context + User string + }{ + Ctx: ctx, + User: user, + } + mock.lockListCodespaces.Lock() + mock.calls.ListCodespaces = append(mock.calls.ListCodespaces, callInfo) + mock.lockListCodespaces.Unlock() + return mock.ListCodespacesFunc(ctx, user) +} + +// ListCodespacesCalls gets all the calls that were made to ListCodespaces. +// Check the length with: +// len(mockedapiClient.ListCodespacesCalls()) +func (mock *apiClientMock) ListCodespacesCalls() []struct { + Ctx context.Context + User string +} { + var calls []struct { + Ctx context.Context + User string + } + mock.lockListCodespaces.RLock() + calls = mock.calls.ListCodespaces + mock.lockListCodespaces.RUnlock() + return calls +} diff --git a/cmd/ghcs/mock_prompter.go b/cmd/ghcs/mock_prompter.go new file mode 100644 index 000000000..56581b64d --- /dev/null +++ b/cmd/ghcs/mock_prompter.go @@ -0,0 +1,69 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package ghcs + +import ( + "sync" +) + +// prompterMock is a mock implementation of prompter. +// +// func TestSomethingThatUsesprompter(t *testing.T) { +// +// // make and configure a mocked prompter +// mockedprompter := &prompterMock{ +// ConfirmFunc: func(message string) (bool, error) { +// panic("mock out the Confirm method") +// }, +// } +// +// // use mockedprompter in code that requires prompter +// // and then make assertions. +// +// } +type prompterMock struct { + // ConfirmFunc mocks the Confirm method. + ConfirmFunc func(message string) (bool, error) + + // calls tracks calls to the methods. + calls struct { + // Confirm holds details about calls to the Confirm method. + Confirm []struct { + // Message is the message argument value. + Message string + } + } + lockConfirm sync.RWMutex +} + +// Confirm calls ConfirmFunc. +func (mock *prompterMock) Confirm(message string) (bool, error) { + if mock.ConfirmFunc == nil { + panic("prompterMock.ConfirmFunc: method is nil but prompter.Confirm was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockConfirm.Lock() + mock.calls.Confirm = append(mock.calls.Confirm, callInfo) + mock.lockConfirm.Unlock() + return mock.ConfirmFunc(message) +} + +// ConfirmCalls gets all the calls that were made to Confirm. +// Check the length with: +// len(mockedprompter.ConfirmCalls()) +func (mock *prompterMock) ConfirmCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockConfirm.RLock() + calls = mock.calls.Confirm + mock.lockConfirm.RUnlock() + return calls +} diff --git a/internal/api/api.go b/internal/api/api.go index fdf5c5b55..bfccfc6c9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -44,12 +44,17 @@ import ( const githubAPI = "https://api.github.com" type API struct { - token string - client *http.Client + token string + client *http.Client + githubAPI string } func New(token string) *API { - return &API{token, &http.Client{}} + return &API{ + token: token, + client: &http.Client{}, + githubAPI: githubAPI, + } } type User struct { @@ -57,7 +62,7 @@ type User struct { } func (a *API) GetUser(ctx context.Context) (*User, error) { - req, err := http.NewRequest(http.MethodGet, githubAPI+"/user", nil) + req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/user", nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -102,7 +107,7 @@ type Repository struct { } func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error) { - req, err := http.NewRequest(http.MethodGet, githubAPI+"/repos/"+strings.ToLower(nwo), nil) + req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+strings.ToLower(nwo), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -135,6 +140,7 @@ type Codespace struct { Name string `json:"name"` GUID string `json:"guid"` CreatedAt string `json:"created_at"` + LastUsedAt string `json:"last_used_at"` Branch string `json:"branch"` RepositoryName string `json:"repository_name"` RepositoryNWO string `json:"repository_nwo"` @@ -168,9 +174,9 @@ type CodespaceEnvironmentConnection struct { RelaySAS string `json:"relaySas"` } -func (a *API) ListCodespaces(ctx context.Context, user *User) ([]*Codespace, error) { +func (a *API) ListCodespaces(ctx context.Context, user string) ([]*Codespace, error) { req, err := http.NewRequest( - http.MethodGet, githubAPI+"/vscs_internal/user/"+user.Login+"/codespaces", nil, + http.MethodGet, a.githubAPI+"/vscs_internal/user/"+user+"/codespaces", nil, ) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) @@ -221,7 +227,7 @@ func (a *API) GetCodespaceToken(ctx context.Context, ownerLogin, codespaceName s req, err := http.NewRequest( http.MethodPost, - githubAPI+"/vscs_internal/user/"+ownerLogin+"/codespaces/"+codespaceName+"/token", + a.githubAPI+"/vscs_internal/user/"+ownerLogin+"/codespaces/"+codespaceName+"/token", bytes.NewBuffer(reqBody), ) if err != nil { @@ -259,7 +265,7 @@ func (a *API) GetCodespaceToken(ctx context.Context, ownerLogin, codespaceName s func (a *API) GetCodespace(ctx context.Context, token, owner, codespace string) (*Codespace, error) { req, err := http.NewRequest( http.MethodGet, - githubAPI+"/vscs_internal/user/"+owner+"/codespaces/"+codespace, + a.githubAPI+"/vscs_internal/user/"+owner+"/codespaces/"+codespace, nil, ) if err != nil { @@ -293,7 +299,7 @@ func (a *API) GetCodespace(ctx context.Context, token, owner, codespace string) func (a *API) StartCodespace(ctx context.Context, token string, codespace *Codespace) error { req, err := http.NewRequest( http.MethodPost, - githubAPI+"/vscs_internal/proxy/environments/"+codespace.GUID+"/start", + a.githubAPI+"/vscs_internal/proxy/environments/"+codespace.GUID+"/start", nil, ) if err != nil { @@ -367,7 +373,7 @@ type SKU struct { } func (a *API) GetCodespacesSKUs(ctx context.Context, user *User, repository *Repository, branch, location string) ([]*SKU, error) { - req, err := http.NewRequest(http.MethodGet, githubAPI+"/vscs_internal/user/"+user.Login+"/skus", nil) + req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/vscs_internal/user/"+user.Login+"/skus", nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -480,7 +486,7 @@ func (a *API) startCreate(ctx context.Context, user string, repository int, sku, return nil, fmt.Errorf("error marshaling request: %w", err) } - req, err := http.NewRequest(http.MethodPost, githubAPI+"/vscs_internal/user/"+user+"/codespaces", bytes.NewBuffer(requestBody)) + req, err := http.NewRequest(http.MethodPost, a.githubAPI+"/vscs_internal/user/"+user+"/codespaces", bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -512,8 +518,13 @@ func (a *API) startCreate(ctx context.Context, user string, repository int, sku, return &response, nil } -func (a *API) DeleteCodespace(ctx context.Context, user *User, token, codespaceName string) error { - req, err := http.NewRequest(http.MethodDelete, githubAPI+"/vscs_internal/user/"+user.Login+"/codespaces/"+codespaceName, nil) +func (a *API) DeleteCodespace(ctx context.Context, user string, codespaceName string) error { + token, err := a.GetCodespaceToken(ctx, user, codespaceName) + if err != nil { + return fmt.Errorf("error getting codespace token: %w", err) + } + + req, err := http.NewRequest(http.MethodDelete, a.githubAPI+"/vscs_internal/user/"+user+"/codespaces/"+codespaceName, nil) if err != nil { return fmt.Errorf("error creating request: %w", err) } @@ -541,7 +552,7 @@ type getCodespaceRepositoryContentsResponse struct { } func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Codespace, path string) ([]byte, error) { - req, err := http.NewRequest(http.MethodGet, githubAPI+"/repos/"+codespace.RepositoryNWO+"/contents/"+path, nil) + req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+codespace.RepositoryNWO+"/contents/"+path, nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 000000000..6fb162030 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestListCodespaces(t *testing.T) { + codespaces := []*Codespace{ + { + Name: "testcodespace", + CreatedAt: "2021-08-09T10:10:24+02:00", + LastUsedAt: "2021-08-09T13:10:24+02:00", + }, + } + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := struct { + Codespaces []*Codespace `json:"codespaces"` + }{ + Codespaces: codespaces, + } + data, _ := json.Marshal(response) + fmt.Fprint(w, string(data)) + })) + defer svr.Close() + + api := API{ + githubAPI: svr.URL, + client: &http.Client{}, + token: "faketoken", + } + ctx := context.TODO() + codespaces, err := api.ListCodespaces(ctx, "testuser") + if err != nil { + t.Fatal(err) + } + + if len(codespaces) != 1 { + t.Fatalf("expected 1 codespace, got %d", len(codespaces)) + } + + if codespaces[0].Name != "testcodespace" { + t.Fatalf("expected testcodespace, got %s", codespaces[0].Name) + } + +}