diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index 667a9c3dc..c45eafb87 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -128,11 +128,18 @@ func (a *API) GetUser(ctx context.Context) (*User, error) { return &response, nil } +// RepositoryOwner represents owner of a repository +type RepositoryOwner struct { + Type string `json:"type"` + Login string `json:"login"` +} + // Repository represents a GitHub repository. type Repository struct { - ID int `json:"id"` - FullName string `json:"full_name"` - DefaultBranch string `json:"default_branch"` + ID int `json:"id"` + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` + Owner RepositoryOwner `json:"owner"` } // GetRepository returns the repository associated with the given owner and name. diff --git a/pkg/cmd/codespace/codespace_selector.go b/pkg/cmd/codespace/codespace_selector.go index 69b9ed06b..a51e42b6f 100644 --- a/pkg/cmd/codespace/codespace_selector.go +++ b/pkg/cmd/codespace/codespace_selector.go @@ -15,6 +15,7 @@ type CodespaceSelector struct { repoName string codespaceName string + repoOwner string } var errNoFilteredCodespaces = errors.New("you have no codespaces meeting the filter criteria") @@ -25,8 +26,10 @@ func AddCodespaceSelector(cmd *cobra.Command, api apiClient) *CodespaceSelector cmd.PersistentFlags().StringVarP(&cs.codespaceName, "codespace", "c", "", "Name of the codespace") cmd.PersistentFlags().StringVarP(&cs.repoName, "repo", "R", "", "Filter codespace selection by repository name (user/repo)") + cmd.PersistentFlags().StringVar(&cs.repoOwner, "repo-owner", "", "Filter codespace selection by repository owner (username or org)") cmd.MarkFlagsMutuallyExclusive("codespace", "repo") + cmd.MarkFlagsMutuallyExclusive("codespace", "repo-owner") return cs } @@ -102,6 +105,10 @@ func (cs *CodespaceSelector) fetchCodespaces(ctx context.Context) (codespaces [] codespaces = filteredCodespaces } + if cs.repoOwner != "" { + codespaces = filterCodespacesByRepoOwner(codespaces, cs.repoOwner) + } + if len(codespaces) == 0 { return nil, errNoFilteredCodespaces } diff --git a/pkg/cmd/codespace/codespace_selector_test.go b/pkg/cmd/codespace/codespace_selector_test.go index 30ba3c588..7b34ebd7b 100644 --- a/pkg/cmd/codespace/codespace_selector_test.go +++ b/pkg/cmd/codespace/codespace_selector_test.go @@ -48,68 +48,101 @@ func TestSelectNameWithCodespaceName(t *testing.T) { func TestFetchCodespaces(t *testing.T) { var ( - repoA1 = &api.Codespace{Name: "1", Repository: api.Repository{FullName: "mock/A"}} - repoA2 = &api.Codespace{Name: "2", Repository: api.Repository{FullName: "mock/A"}} + octocatOwner = api.RepositoryOwner{Login: "octocat"} + cliOwner = api.RepositoryOwner{Login: "cli"} + octocatA = &api.Codespace{ + Name: "1", + Repository: api.Repository{FullName: "octocat/A", Owner: octocatOwner}, + } - repoB1 = &api.Codespace{Name: "1", Repository: api.Repository{FullName: "mock/B"}} + octocatA2 = &api.Codespace{ + Name: "2", + Repository: api.Repository{FullName: "octocat/A", Owner: octocatOwner}, + } + + cliA = &api.Codespace{ + Name: "3", + Repository: api.Repository{FullName: "cli/A", Owner: cliOwner}, + } + + octocatB = &api.Codespace{ + Name: "4", + Repository: api.Repository{FullName: "octocat/B", Owner: octocatOwner}, + } ) tests := []struct { tName string apiCodespaces []*api.Codespace + codespaceName string repoName string + repoOwner string wantCodespaces []*api.Codespace wantErr error }{ // Empty case { - "empty", nil, "", nil, errNoCodespaces, + tName: "empty", + apiCodespaces: nil, + wantCodespaces: nil, + wantErr: errNoCodespaces, }, // Tests with no filtering { - "no filtering, single codespace", - []*api.Codespace{repoA1}, - "", - []*api.Codespace{repoA1}, - nil, + tName: "no filtering, single codespaces", + apiCodespaces: []*api.Codespace{octocatA}, + wantCodespaces: []*api.Codespace{octocatA}, + wantErr: nil, }, { - "no filtering, multiple codespaces", - []*api.Codespace{repoA1, repoA2, repoB1}, - "", - []*api.Codespace{repoA1, repoA2, repoB1}, - nil, + tName: "no filtering, multiple codespace", + apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB}, + wantCodespaces: []*api.Codespace{octocatA, cliA, octocatB}, }, // Test repo filtering { - "repo filtering, single codespace", - []*api.Codespace{repoA1}, - "mock/A", - []*api.Codespace{repoA1}, - nil, + tName: "repo name filtering, single codespace", + apiCodespaces: []*api.Codespace{octocatA}, + repoName: "octocat/A", + wantCodespaces: []*api.Codespace{octocatA}, + wantErr: nil, }, { - "repo filtering, multiple codespaces", - []*api.Codespace{repoA1, repoA2, repoB1}, - "mock/A", - []*api.Codespace{repoA1, repoA2}, - nil, + tName: "repo name filtering, multiple codespace", + apiCodespaces: []*api.Codespace{octocatA, octocatA2, cliA, octocatB}, + repoName: "octocat/A", + wantCodespaces: []*api.Codespace{octocatA, octocatA2}, + wantErr: nil, }, { - "repo filtering, multiple codespaces 2", - []*api.Codespace{repoA1, repoA2, repoB1}, - "mock/B", - []*api.Codespace{repoB1}, - nil, + tName: "repo name filtering, multiple codespace 2", + apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB}, + repoName: "octocat/B", + wantCodespaces: []*api.Codespace{octocatB}, + wantErr: nil, }, { - "repo filtering, no matches", - []*api.Codespace{repoA1, repoA2, repoB1}, - "mock/C", - nil, - errNoFilteredCodespaces, + tName: "repo name filtering, no matches", + apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB}, + repoName: "Unknown/unknown", + wantCodespaces: nil, + wantErr: errNoFilteredCodespaces, + }, + { + tName: "repo filtering, match with repo owner", + apiCodespaces: []*api.Codespace{octocatA, octocatA2, cliA, octocatB}, + repoOwner: "octocat", + wantCodespaces: []*api.Codespace{octocatA, octocatA2, octocatB}, + wantErr: nil, + }, + { + tName: "repo filtering, no match with repo owner", + apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB}, + repoOwner: "unknown", + wantCodespaces: []*api.Codespace{}, + wantErr: errNoFilteredCodespaces, }, } @@ -121,7 +154,11 @@ func TestFetchCodespaces(t *testing.T) { }, } - cs := &CodespaceSelector{api: api, repoName: tt.repoName} + cs := &CodespaceSelector{ + api: api, + repoName: tt.repoName, + repoOwner: tt.repoOwner, + } codespaces, err := cs.fetchCodespaces(context.Background()) diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index 8ccc52a4f..f6f6fb937 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -10,6 +10,7 @@ import ( "log" "os" "sort" + "strings" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" @@ -266,3 +267,14 @@ func addDeprecatedRepoShorthand(cmd *cobra.Command, target *string) error { return nil } + +// filterCodespacesByRepoOwner filters a list of codespaces by the owner of the repository. +func filterCodespacesByRepoOwner(codespaces []*api.Codespace, repoOwner string) []*api.Codespace { + filtered := make([]*api.Codespace, 0, len(codespaces)) + for _, c := range codespaces { + if strings.EqualFold(c.Repository.Owner.Login, repoOwner) { + filtered = append(filtered, c) + } + } + return filtered +} diff --git a/pkg/cmd/codespace/delete.go b/pkg/cmd/codespace/delete.go index d67c271ad..5a7028dc1 100644 --- a/pkg/cmd/codespace/delete.go +++ b/pkg/cmd/codespace/delete.go @@ -23,6 +23,7 @@ type deleteOptions struct { keepDays uint16 orgName string userName string + repoOwner string isInteractive bool now func() time.Time @@ -60,10 +61,12 @@ func newDeleteCmd(app *App) *cobra.Command { // After the admin subcommand is added (see https://github.com/cli/cli/pull/6944#issuecomment-1419553639) we can revisit this. opts.codespaceName = selector.codespaceName opts.repoFilter = selector.repoName + opts.repoOwner = selector.repoOwner if opts.deleteAll && opts.repoFilter != "" { return cmdutil.FlagErrorf("both `--all` and `--repo` is not supported") } + if opts.orgName != "" && opts.codespaceName != "" && opts.userName == "" { return cmdutil.FlagErrorf("using `--org` with `--codespace` requires `--user`") } @@ -99,6 +102,9 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) { userName = currentUser.Login } codespaces, fetchErr = a.apiClient.ListCodespaces(ctx, api.ListCodespacesOptions{OrgName: opts.orgName, UserName: userName}) + if opts.repoOwner != "" { + codespaces = filterCodespacesByRepoOwner(codespaces, opts.repoOwner) + } return }) if err != nil { @@ -139,6 +145,7 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) { if opts.repoFilter != "" && !strings.EqualFold(c.Repository.FullName, opts.repoFilter) { continue } + if opts.keepDays > 0 { t, err := time.Parse(time.RFC3339, c.LastUsedAt) if err != nil { diff --git a/pkg/cmd/codespace/delete_test.go b/pkg/cmd/codespace/delete_test.go index ca6fe989e..0a5a76956 100644 --- a/pkg/cmd/codespace/delete_test.go +++ b/pkg/cmd/codespace/delete_test.go @@ -217,6 +217,44 @@ func TestDelete(t *testing.T) { wantDeleted: []string{"monalisa-spoonknife-123"}, wantStdout: "", }, + { + name: "by repo owner", + opts: deleteOptions{ + deleteAll: true, + repoOwner: "octocat", + }, + codespaces: []*api.Codespace{ + { + Name: "octocat-spoonknife-123", + Repository: api.Repository{ + FullName: "octocat/Spoon-Knife", + Owner: api.RepositoryOwner{ + Login: "octocat", + }, + }, + }, + { + Name: "cli-robawt-abc", + Repository: api.Repository{ + FullName: "cli/ROBAWT", + Owner: api.RepositoryOwner{ + Login: "cli", + }, + }, + }, + { + Name: "octocat-spoonknife-c4f3", + Repository: api.Repository{ + FullName: "octocat/Spoon-Knife", + Owner: api.RepositoryOwner{ + Login: "octocat", + }, + }, + }, + }, + wantDeleted: []string{"octocat-spoonknife-123", "octocat-spoonknife-c4f3"}, + wantStdout: "", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {