Merge pull request #79 from github/raffo/delete-codespaces

Add code and command to delete unused codespaces
This commit is contained in:
Mislav Marohnić 2021-09-24 16:02:05 +02:00 committed by GitHub
commit 92d0abd6ab
8 changed files with 810 additions and 183 deletions

View file

@ -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
}

View file

@ -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,
},
},
}

239
cmd/ghcs/delete_test.go Normal file
View file

@ -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
}

View file

@ -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)
}

292
cmd/ghcs/mock_api.go Normal file
View file

@ -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
}

69
cmd/ghcs/mock_prompter.go Normal file
View file

@ -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
}

View file

@ -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)
}

50
internal/api/api_test.go Normal file
View file

@ -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)
}
}